Skip to content

fix(relay): close per-trade and per-order subscriptions on task exit - #365

Merged
grunch merged 3 commits into
mainfrom
fix/close-relay-subscriptions
Sep 7, 2026
Merged

fix(relay): close per-trade and per-order subscriptions on task exit#365
grunch merged 3 commits into
mainfrom
fix/close-relay-subscriptions

Conversation

@grunch

@grunch grunch commented Aug 31, 2026

Copy link
Copy Markdown
Member

Phase 2, PR 2.6 of the optimization plan (#348). Independent of the other Phase 2 PRs. Supersedes the functional part of #255.

Problem

Two subscriptions were opened with an auto-generated id and never closed:

  • subscribe_daemon_messages — one per create/take/restore
  • subscribe_single_order — one per taken order

Both tasks exit after 30 minutes idle, but the relay-side REQ lives on for the rest of the session. There is no unsubscribe anywhere on those paths, so an active session leaks one subscription per trade action.

Relays cap concurrent REQs per connection. Measured on relay.mostro.network: ~18 are accepted, then every newcomer is refused with CLOSED: Number of subscriptions exceeds limit. Already-open subscriptions (the order book included) are not evicted. So the symptom is: past roughly 18 trade actions in one session, new per-trade subscriptions are silently refused and the client falls back to the bulk mostro-dm feed — which works, but replays history and loses the limit(0) live-only guarantee of the dedicated feed.

Change

Both now subscribe with a stable id and unsubscribe on exit, mirroring the pattern already established for chat in messages.rs (chat_subscription_id + the cleanup block). Ids are keyed by trade pubkey and order id respectively, so one task's exit cannot close another task's feed — or the order book's.

Subscription ids stay within NIP-01's 64-character cap. The daemon id is keyed by the first 32 hex characters of the trade pubkey (128 bits → a 46-char id). The full 64-hex pubkey would have produced a 78-char id, which relays reject with an asynchronous CLOSED that the client only logs — i.e. a subscription that silently never exists.

Test plan

  • every_subscription_id_addresses_one_feed: ids are stable across calls and distinct across trades, orders, the order book, the recent-orders window, the relay list and the global DM feed. A collision here would be worse than the leak: one trade finishing would close another's subscription.
  • subscription_ids_fit_nip01: every generator produces an id of at most 64 chars. Without this, the next prefix change crosses the limit again and the bulk feed hides it again.
  • daemon_ids_stay_distinct_past_the_truncation_point: two keys that differ within the kept prefix map to different ids.
  • cargo test — 411 passed, 0 failed (on the merge with current main)
  • cargo clippy --locked -- -D warnings — clean
  • ./scripts/frb-generate.sh --check — clean, no bridge surface change
  • Manual: take several orders in one session and confirm the relay's active REQ count stays bounded.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9Bt2LnEXfxMtvDkMswBAp

subscribe_daemon_messages and subscribe_single_order opened relay
subscriptions with an auto-generated id and never closed them. Both
tasks exit after 30 minutes idle, but the relay-side REQ lived on for
the rest of the session, so every create and every take leaked one.

Public relays cap concurrent REQs at roughly 10-20. Past the cap they
answer CLOSED, which is only logged -- and the subscription they drop
can be the order book's own feed, so a heavy session degrades into an
order list that silently stops updating.

Both now use stable ids and unsubscribe on exit, mirroring the pattern
already established for chat subscriptions in messages.rs. Ids are keyed
by trade pubkey and order id so one task's exit cannot close another's
feed -- which is what the test pins.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 57 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d4d19793-b7c2-4d1b-b999-4c8a27225fb6

📥 Commits

Reviewing files that changed from the base of the PR and between 0ce3d14 and 42d0d7d.

📒 Files selected for processing (1)
  • rust/src/api/orders.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T11:50:00.186450Z 4817e4d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4817e4dfb9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust/src/api/orders.rs Outdated

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 4817e4d, merged locally against current main (3641db9 — the branch is 9 commits behind and merges clean). On that merge: 294 Rust tests pass, cargo clippy --locked -- -D warnings and cargo check --locked --target wasm32-unknown-unknown — the exact CI commands — are clean.

The leak this closes is real, and I confirmed the cap it's based on. But as it stands the change replaces a working subscription with one that no relay accepts, so the mechanism it introduces never runs.

Overlap with #255 — and why I'd keep this one

#255 (feat(#182)) does the same two things in the same two functions: deterministic ids via subscribe_with_id, plus unsubscribe at each loop's exit. The two are mutually redundant and will conflict.

I'd continue with this PR, for reasons of substance rather than seniority:

  • The diff. This is +60/−3. #255 is +497/−265, because a merge commit ran cargo fmt over the whole of orders.rs (main's copy has 1263 lines of rustfmt drift; the branch's has zero). The functional change there is ~70 lines; the rest is churn that would conflict with the 13 open PRs that touch orders.rs.
  • The comments. #255 still carries three comment blocks that describe an ownership guard removed in an earlier round, and a doc-comment claiming "full trade pubkey hex, not a prefix" above a function that slices [..32].
  • The series. This is part of the coordinated #348 Phase 2 work.

But #255 is ahead on one thing that matters here, and it has to be carried over before this merges — see below. I'd suggest closing #255 as superseded once this lands, with the 64-char finding (which came out of its review) and its length test brought over.

Blocking: mostro-daemon-<trade_pubkey_hex> is 78 characters; NIP-01 caps ids at 64

daemon_message_subscription_id builds "mostro-daemon-" + 64 hex = 78. SubscriptionId::new performs no length validation and subscribe_with_id returns Ok regardless, so nothing surfaces locally.

Probed against both entries of DEFAULT_RELAYS, same filter on three ids at once — this PR's, #255's 45-char id, and a short control:

[probe] pr365_len=78  pr255_len=45  ctrl_len=10
[probe] subscribe len=78 -> ok=true
[probe] CLOSED relay=wss://relay.mostro.network id_len=78 msg=Subscription id should be non-empty string of max length 64 chars
[probe] CLOSED relay=wss://nos.lol             id_len=78 msg=ERROR: bad req: invalid subscription id length
[probe] RESULT pr365(78)=0   pr255(45)=10   ctrl(10)=10

Zero events, on both relays, with independently worded errors — this is the NIP being enforced, not one relay's quirk.

It is a regression. On main the same subscription uses client.subscribe(filter, None), and nostr-sdk generates 16 bytes of hex — 32 characters, well inside the limit. So this change takes a subscription that works today and makes it one the relay refuses.

Why it isn't visible. Three layers hide it:

  1. subscribe_with_id returns Ok — the REQ was sent; the refusal comes later.
  2. The CLOSED arrives asynchronously and the relay-notification loop only logs it (blog_warn "relay" closed sub=… msg=…). Nothing propagates it, nothing retries.
  3. The daemon's messages for that trade key still arrive over the global mostro-dm bulk feed, since ensure_global_dm_coverage includes the key. Create, take and restore keep working, so manual testing passes.

Meanwhile the watcher loops for its full 30-minute idle window over a subscription that was never established, and unsubscribes it on exit. The limit(0) live-only replay protection on that filter is also not in effect, because there is no subscription — correlation falls entirely to the bulk feed, which replays history. The request_id nonce still guards it, so no visible bug, but the property is gone.

Where the budget was lost. The pattern this mirrors — chat_subscription_id (messages.rs:889) — is "mostro-chat-" + "dispute-"? + a 36-char order id = 56 max. It fits because the key is an order id. Substituting a 64-char pubkey hex into the same shape crosses the limit.

The fix

Truncate: format!("mostro-daemon-{}", &trade_pubkey_hex[..32]) gives 46 characters. 128 bits rules out any realistic cross-trade collision, and it leaves headroom for a later prefix change.

Please also extend every_subscription_id_addresses_one_feed (or add a sibling) with a length invariant over both generators:

assert!(daemon_message_subscription_id(&a).to_string().len() <= 64);
assert!(single_order_subscription_id(order_id).to_string().len() <= 64);

The existing collision test is the right test to have — it's the one thing #255 lacks — but it passes happily on an id no relay will accept. Without the length assert, the next prefix change crosses the limit again and the bulk feed hides it again.

Blocking: the new doc-comment landed on the wrong function

The helpers were inserted above orders_subscription_id rather than below it, so /// Stable subscription ID for the Kind 38383 order-book feed. now sits directly on top of /// Stable id for a trade's daemon-message subscription.daemon_message_subscription_id carries two contradictory doc lines, and orders_subscription_id is left with none. One line to move.

The failure mode in the description is not the one that happens

I measured the cap on relay.mostro.network (40 REQs on one connection, with a stand-in "order book" subscription opened first):

[probe] eose_count=19 closed_count=22
[probe] CLOSED probe-018: Number of subscriptions exceeds limit
[probe] CLOSED probe-019: Number of subscriptions exceeds limit
…
[probe] book_was_closed=false

The relay accepts ~18 concurrent subscriptions and then rejects the newcomer; it does not evict anything already open — the order-book stand-in stayed alive and got its EOSE. So the symptom isn't "an order list that quietly stops updating"; it's "past roughly 18 trade actions in a session, new per-trade subscriptions are silently refused" (and the client falls back to the bulk feed). The leak is worth closing either way, but the description overstates the damage and should say what actually happens.

Not blocking, worth recording

Two watchers can share mostro-order-<uuid>. The id is keyed by order id, not by trade key. take_order only refuses when order.status != Pending, so an order that returns to Pending (taker timeout, cancellation) and is taken again in the same session starts a second watcher on the same id. Harmless in the normal case — both read the same client.notifications() broadcast, reset their idle timers on the same events and exit together — except that subscribe_single_order's recv arm is Ok(Err(_)) => break, which treats a broadcast Lagged as fatal. A lagged older watcher would then tear down the younger one's REQ and leave it looping on nothing for up to 30 minutes. subscribe_daemon_messages gets this right (Lagged => continue, Closed => break). Matching the two arms is a one-line change; otherwise this belongs on #325.

Also verified

  • Merge with current main is clean, one file, +60/−3.
  • Unsubscribing doesn't blind the client: ensure_global_dm_coverage runs on all three paths that open a per-trade watcher (create_order, take_order, restore_session) and keeps every derived trade key in the bulk Kind-14 filter for the life of the process. That's what makes the teardown safe.
  • One watcher per daemon-message id: all three callers call derive_trade_key() first, so the pubkey — and the id — is fresh each time. Unconditional cleanup is correct there.
  • No privacy regression: the deterministic id shows the relay a trade pubkey / order id that the same subscription's filter already carries.
  • Every loop exit reaches the cleanup; the only early return is the failed-subscribe path, where there is nothing to tear down.
  • limit(0) is untouched — only the id changes.

@grunch

grunch commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Addressed the review at 42d0d7d, on top of a merge with current main (6999867).

Merge with main. main moved to nostr-sdk 0.45 (subscribe(filter).with_id(id), unsubscribe returning a Result) and added recent_orders_subscription_id. Resolved in the merge commit with no functional change beyond adapting the two call sites; both unsubscribe results are now logged rather than dropped.

Blocking — 78-char id. The daemon id is keyed by trade_pubkey_hex[..32] (46 chars). New subscription_ids_fit_nip01 asserts every generator (both new ones plus orders, recent_orders, relay_list, mostro_dm) stays at or under 64, and daemon_ids_stay_distinct_past_the_truncation_point guards the cut. The collision test now includes the two feeds added on main.

Blocking — misplaced doc-comment. The helpers moved below orders_subscription_id, which has its own doc line back.

Description. Rewritten to say what actually happens: the relay refuses newcomers past the cap and the client falls back to the bulk feed; nothing already open is evicted.

Lagged in subscribe_single_order. Moot after the merge: both watchers on main now consume client.notifications() as a stream (rx.next()), so the Ok(Err(_)) => break arm no longer exists.

Verified on the merged branch: 411 tests pass, cargo clippy --locked -- -D warnings clean, frb-generate.sh --check clean.

Resolves the conflict with the nostr-sdk 0.45 subscribe API
(`subscribe(filter).with_id(id)`, `unsubscribe` now returns a Result),
the new `recent_orders_subscription_id` helper block, and the test module.
`mostro-daemon-<64 hex>` is 78 characters; NIP-01 caps subscription ids
at 64 and relays enforce it with an asynchronous CLOSED that the client
only logs. So the stable id replaced a working subscription with one no
relay accepted, silently: create/take kept working over the bulk
`mostro-dm` feed, and the watcher looped for its idle window over a
subscription that never existed.

- Key the daemon id by the first 32 hex chars of the trade pubkey
  (128 bits, 46-char id) — enough to rule out cross-trade collisions and
  leave headroom for a later prefix change.
- Move the misplaced order-book doc-comment back onto
  `orders_subscription_id`; the helpers now sit below it.
- Add `subscription_ids_fit_nip01`, which bounds every generator, and a
  guard that truncation cannot merge two keys differing past the cut.
  Extend the collision test with the feeds added on main
  (`recent_orders`, `relay_list`).
@grunch
grunch force-pushed the fix/close-relay-subscriptions branch from 0ec51be to 42d0d7d Compare September 7, 2026 14:02
@grunch
grunch merged commit 7e18110 into main Sep 7, 2026
6 of 7 checks passed
@grunch
grunch deleted the fix/close-relay-subscriptions branch September 7, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants